ChatSidebar.tsx 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  1. 'use client';
  2. import { useState, useRef, useEffect, useCallback, type KeyboardEvent } from 'react';
  3. import useAuth from '@/hooks/useAuth';
  4. import useChat from '@/hooks/useChat';
  5. import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
  6. import { faUsers, faEllipsisVertical, faPaperPlane, faTrashCan, faMagnifyingGlassPlus, faMagnifyingGlassMinus, faRotateRight, faClock, faCoins } from '@fortawesome/free-solid-svg-icons';
  7. import './chat-sidebar.scss';
  8. const MIN_FONT_SIZE = 11;
  9. const MAX_FONT_SIZE = 19;
  10. const DEFAULT_FONT_SIZE = 13;
  11. type ChatSidebarProps = {
  12. onDonate?: () => void;
  13. };
  14. export default function ChatSidebar({ onDonate }: ChatSidebarProps = {}) {
  15. const { isAuthenticated } = useAuth();
  16. const { messages, systemMessages, participantCount, participants, sendMessage, clearMessages, refreshChat, requestParticipants, chatConnected } = useChat();
  17. const [inputValue, setInputValue] = useState('');
  18. const [fontSize, setFontSize] = useState(DEFAULT_FONT_SIZE);
  19. const [showMenu, setShowMenu] = useState(false);
  20. const [showTime, setShowTime] = useState(false);
  21. const messagesRef = useRef<HTMLDivElement>(null);
  22. const isAutoScrollRef = useRef(true);
  23. const [showParticipants, setShowParticipants] = useState(false);
  24. const menuRef = useRef<HTMLDivElement>(null);
  25. // 메시지 + 시스템 메시지를 시간순 병합
  26. const mergedMessages = (() => {
  27. const items: Array<
  28. | { type: 'chat'; data: typeof messages[number] }
  29. | { type: 'system'; data: typeof systemMessages[number] }
  30. > = [];
  31. messages.forEach((m) => items.push({ type: 'chat', data: m }));
  32. systemMessages.forEach((m) => items.push({ type: 'system', data: m }));
  33. items.sort((a, b) => {
  34. const timeA = a.type === 'chat' ? a.data.sentAt : a.data.receivedAt;
  35. const timeB = b.type === 'chat' ? b.data.sentAt : b.data.receivedAt;
  36. return new Date(timeA).getTime() - new Date(timeB).getTime();
  37. });
  38. return items;
  39. })();
  40. // 자동 스크롤
  41. useEffect(() => {
  42. const el = messagesRef.current;
  43. if (!el || !isAutoScrollRef.current) return;
  44. el.scrollTop = el.scrollHeight;
  45. }, [mergedMessages.length]);
  46. // 스크롤 이벤트로 자동 스크롤 제어
  47. const handleScroll = useCallback(() => {
  48. const el = messagesRef.current;
  49. if (!el) return;
  50. const threshold = 50;
  51. isAutoScrollRef.current = el.scrollTop + el.clientHeight >= el.scrollHeight - threshold;
  52. }, []);
  53. // 메뉴 외부 클릭 닫기
  54. useEffect(() => {
  55. if (!showMenu) return;
  56. const handleClick = (e: MouseEvent) => {
  57. if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
  58. setShowMenu(false);
  59. }
  60. };
  61. document.addEventListener('mousedown', handleClick);
  62. return () => document.removeEventListener('mousedown', handleClick);
  63. }, [showMenu]);
  64. const handleSend = useCallback(() => {
  65. if (!inputValue.trim()) return;
  66. sendMessage(inputValue);
  67. setInputValue('');
  68. }, [inputValue, sendMessage]);
  69. const handleKeyDown = useCallback((e: KeyboardEvent<HTMLInputElement>) => {
  70. if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
  71. e.preventDefault();
  72. handleSend();
  73. }
  74. }, [handleSend]);
  75. const handleClear = useCallback(() => {
  76. clearMessages();
  77. setShowMenu(false);
  78. }, [clearMessages]);
  79. const handleFontIncrease = useCallback(() => {
  80. setFontSize((prev) => Math.min(prev + 2, MAX_FONT_SIZE));
  81. setShowMenu(false);
  82. }, []);
  83. const handleFontDecrease = useCallback(() => {
  84. setFontSize((prev) => Math.max(prev - 2, MIN_FONT_SIZE));
  85. setShowMenu(false);
  86. }, []);
  87. const handleRefresh = useCallback(() => {
  88. setShowMenu(false);
  89. refreshChat();
  90. }, [refreshChat]);
  91. const handleToggleTime = useCallback(() => {
  92. setShowTime((prev) => !prev);
  93. setShowMenu(false);
  94. }, []);
  95. const handleShowParticipants = useCallback(() => {
  96. requestParticipants();
  97. setShowParticipants(true);
  98. }, [requestParticipants]);
  99. const handleCloseParticipants = useCallback(() => {
  100. setShowParticipants(false);
  101. }, []);
  102. const formatTime = (dateStr: string) => {
  103. const date = new Date(dateStr);
  104. return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
  105. };
  106. return (
  107. <div className='chat-sidebar'>
  108. {/* 헤더 */}
  109. <div className='chat-header'>
  110. <span className='chat-header-title'>실시간 채팅</span>
  111. <div className='chat-header-actions'>
  112. <button type='button' className='chat-participant-count' title='참여자 보기' onClick={handleShowParticipants}>
  113. <FontAwesomeIcon icon={faUsers} />
  114. <span>{participantCount}명</span>
  115. </button>
  116. <div className='chat-menu-wrapper' ref={menuRef}>
  117. <button type='button' title='메뉴' onClick={() => setShowMenu((prev) => !prev)}>
  118. <FontAwesomeIcon icon={faEllipsisVertical} />
  119. </button>
  120. {showMenu && (
  121. <div className='chat-menu'>
  122. <button type='button' onClick={handleClear}>
  123. <FontAwesomeIcon icon={faTrashCan} />
  124. <span>채팅 지우기</span>
  125. </button>
  126. <button type='button' onClick={handleToggleTime}>
  127. <FontAwesomeIcon icon={faClock} />
  128. <span>시간 {showTime ? '숨기기' : '보기'}</span>
  129. </button>
  130. <button type='button' onClick={handleFontIncrease} disabled={fontSize >= MAX_FONT_SIZE}>
  131. <FontAwesomeIcon icon={faMagnifyingGlassPlus} />
  132. <span>글자 크게</span>
  133. </button>
  134. <button type='button' onClick={handleFontDecrease} disabled={fontSize <= MIN_FONT_SIZE}>
  135. <FontAwesomeIcon icon={faMagnifyingGlassMinus} />
  136. <span>글자 작게</span>
  137. </button>
  138. <button type='button' onClick={handleRefresh}>
  139. <FontAwesomeIcon icon={faRotateRight} />
  140. <span>새로고침</span>
  141. </button>
  142. </div>
  143. )}
  144. </div>
  145. </div>
  146. </div>
  147. {/* 메시지 영역 */}
  148. <div className='chat-messages' ref={messagesRef} onScroll={handleScroll} style={{ fontSize: `${fontSize}px` }}>
  149. {!chatConnected && (
  150. <div className='chat-system'>채팅 서버에 연결 중...</div>
  151. )}
  152. {mergedMessages.map((item, index) => {
  153. if (item.type === 'system') {
  154. return (
  155. <div key={`sys-${item.data.id}`} className='chat-system'>
  156. {item.data.content}
  157. </div>
  158. );
  159. }
  160. const msg = item.data;
  161. return (
  162. <div key={`msg-${index}`} className='chat-message'>
  163. {showTime && <span className='chat-message-time'>{formatTime(msg.sentAt)}</span>}
  164. <span className='chat-message-name'>{msg.memberName || msg.memberSID}</span>
  165. <span className='chat-message-content'>{msg.content}</span>
  166. </div>
  167. );
  168. })}
  169. </div>
  170. {/* 입력 영역 */}
  171. <div className='chat-input-area'>
  172. {isAuthenticated ? (
  173. <div className='chat-input-row'>
  174. <input
  175. type='text'
  176. placeholder='메시지를 입력하세요'
  177. value={inputValue}
  178. onChange={(e) => setInputValue(e.target.value)}
  179. onKeyDown={handleKeyDown}
  180. maxLength={500}
  181. disabled={!chatConnected}
  182. />
  183. {onDonate && (
  184. <button type='button' title='후원하기' onClick={onDonate} className='chat-donate-btn'>
  185. <FontAwesomeIcon icon={faCoins} />
  186. </button>
  187. )}
  188. <button type='button' title='전송' onClick={handleSend} disabled={!chatConnected || !inputValue.trim()}>
  189. <FontAwesomeIcon icon={faPaperPlane} />
  190. </button>
  191. </div>
  192. ) : (
  193. <div className='chat-login-notice'>
  194. <a href='/login'>로그인</a> 후 채팅에 참여하세요
  195. </div>
  196. )}
  197. </div>
  198. {/* 참여자 목록 패널 */}
  199. {showParticipants && (
  200. <>
  201. <div className='chat-participants-overlay' onClick={handleCloseParticipants} />
  202. <div className='chat-participants-dialog'>
  203. <div className='chat-participants-header'>
  204. <h3>참여자 ({participantCount}명)</h3>
  205. <button type='button' onClick={handleCloseParticipants} aria-label='닫기'>&times;</button>
  206. </div>
  207. <ul className='chat-participants-list'>
  208. {participants.length === 0 && participantCount === 0 ? (
  209. <li className='chat-participants-empty'>참여자가 없습니다</li>
  210. ) : (
  211. <>
  212. {participants.map((p) => (
  213. <li key={p.memberName}>
  214. <FontAwesomeIcon icon={faUsers} className='chat-participant-icon' />
  215. <span>{p.memberName}</span>
  216. </li>
  217. ))}
  218. {participantCount - participants.length > 0 && (
  219. <li className='chat-participants-guest'>
  220. <span>비회원 {participantCount - participants.length}명</span>
  221. </li>
  222. )}
  223. </>
  224. )}
  225. </ul>
  226. </div>
  227. </>
  228. )}
  229. </div>
  230. );
  231. }